8  Веб-скрапинг

Файлы html, как и XML, хранят данные в структурированном виде. Извлечь их позволяет пакет rvest. С его помощью мы добудем архив телеграм-канала Antibarbari HSE. Канал публичный, и Telegram дает возможность скачать архив в формате html при помощи кнопки export (эта функция может быть недоступна на MacOS, в этом случае стоит попробовать Telegram Lite).

Эта глава опирается в основом на второе издание книги R for Data Science Хадли Викхема.

8.1 Структура html

Документы html (HyperText Markup Language) имеют ирархическую структуру, состоящую из элементов. В каждом элементе есть открывающий тег (<tag>), опциональные атрибуты (id='first') и закрывающий тег (</tag>). Все, что находится между открывающим и закрывающим тегом, называется содержанием элемента.

Важнейшие теги, о которых стоит знать:

  • <html> (есть всегда), с двумя детьми (дочерними элементами): <head> и <body>
  • элементы, отвечающие за структуру: <h1> (заголовок), <section>, <p> (параграф), <ol> (упорядоченный список)
  • элементы, отвечающие за оформление: <b> (bold), <i> (italics), <a> (ссылка)

Чтобы увидеть структуру веб-страницы, надо нажать правую кнопку мыши и выбрать View Source (это работает и для тех html, которые хранятся у вас на компьютере).

8.2 Каскадные таблицы стилей

У тегов могут быть именованные атрибуты; важнейшие из них – это id и class, которые в сочетании с CSS контролируют внешний вид страницы.

На заметку

CSS (англ. Cascading Style Sheets «каскадные таблицы стилей») — формальный язык декорирования и описания внешнего вида документа (веб-страницы), написанного с использованием языка разметки (чаще всего HTML или XHTML).

Пример css-правила (такие инфобоксы использованы в предыдущей версии курса):

.infobox {
  padding: 1em 1em 1em 4em;
  background: aliceblue 5px center/3em no-repeat;
  color: black;
}

Проще говоря, это инструкция, что делать с тем или иным элементом. Каждое правило CSS имеет две основные части — селектор и блок объявлений. Селектор, расположенный в левой части правила до знака {, определяет, на какие части документа (возможно, специально обозначенные) распространяется правило. Блок объявлений располагается в правой части правила. Он помещается в фигурные скобки, и, в свою очередь, состоит из одного или более объявлений, разделённых знаком «;».

Селекторы CSS полезны для скрапинга, потому что они помогают вычленить необходимые элементы. Это работает так:

  • p выберет все элементы <p>
  • .title выберет элементы с классом “title”
  • #title выберет все элементы с атрибутом id=‘title’

Важно: если изменится структура страницы, откуда вы скрапили информацию, то и код придется переписывать.

8.3 Чтение html

Чтобы прочесть файл html, используем одноименную функцию.

library(rvest)
antibarbari_files <- list.files("../files/antibarbari_2024-08-18", pattern = "html", full.names = TRUE)

Используем пакет purrr, чтобы прочитать сразу три файла из архива.

library(tidyverse)
antibarbari_archive <- map(antibarbari_files, read_html)

8.4 Парсинг html: отдельные элементы

На следующем этапе важно понять, какие именно элементы нужны. Рассмотрим на примере одного сообщения. Для примера я сохраню этот элемент как небольшой отдельный html; rvest позволяет это сделать (но внутри двойных кавычек должны быть только одинарные):

example_html <-  minimal_html("
<div class='message default clearfix' id='message83'>
      <div class='pull_left userpic_wrap'>
       <div class='userpic userpic2' style='width: 42px; height: 42px'>
        <div class='initials' style='line-height: 42px'>
A
        </div>
       </div>
      </div>
      <div class='body'>
       <div class='pull_right date details' title='19.05.2022 11:18:07 UTC+03:00'>
11:18
       </div>
       <div class='from_name'>
Antibarbari HSE 
       </div>
       <div class='text'>
Этот пост открывает серию переложений из «Дайджеста платоновских идиом» Джеймса Ридделла (1823–1866), английского филолога-классика, чей научный путь был связан с Оксфордским университетом. По приглашению Бенджамина Джоветта он должен был подготовить к изданию «Апологию», «Критон», «Федон» и «Пир». Однако из этих четырех текстов вышла лишь «Апология» с предисловием и приложением в виде «Дайджеста» (ссылка) — уже после смерти автора. <br><br>«Дайджест» содержит 326 параграфов, посвященных грамматическим, синтаксическим и риторическим особенностям языка Платона. Знакомство с этим теоретическим материалом позволяет лучше почувствовать уникальный стиль философа и добиться большей точности при переводе. Ссылки на «Дайджест» могут быть уместны и в учебных комментариях к диалогам Платона. Предлагаемая здесь первая часть «Дайджеста» содержит «идиомы имен» и «идиомы артикля» (§§ 1–39).<br><a href='http://antibarbari.ru/2022/05/19/digest_1/'>http://antibarbari.ru/2022/05/19/digest_1/</a>
       </div>
       <div class='signature details'>
Olga Alieva
       </div>
      </div>
     </div>
")

Из всего этого мне может быть интересно id сообщения (\<div class='message default clearfix' id='message83'\>), текст сообщения (\<div class='text'\>), дата публикации (\<div class='pull_right date details' title='19.05.2022 11:18:07 UTC+03:00'\>), а также, если указан, автор сообщения (\<div class='signature details'\>). Извлекаем текст (для этого рекомендуется использовать функцию html_text2()):

example_html |>
  html_element(".text") |> 
  html_text2()
[1] "Этот пост открывает серию переложений из «Дайджеста платоновских идиом» Джеймса Ридделла (1823–1866), английского филолога-классика, чей научный путь был связан с Оксфордским университетом. По приглашению Бенджамина Джоветта он должен был подготовить к изданию «Апологию», «Критон», «Федон» и «Пир». Однако из этих четырех текстов вышла лишь «Апология» с предисловием и приложением в виде «Дайджеста» (ссылка) — уже после смерти автора.\n\n«Дайджест» содержит 326 параграфов, посвященных грамматическим, синтаксическим и риторическим особенностям языка Платона. Знакомство с этим теоретическим материалом позволяет лучше почувствовать уникальный стиль философа и добиться большей точности при переводе. Ссылки на «Дайджест» могут быть уместны и в учебных комментариях к диалогам Платона. Предлагаемая здесь первая часть «Дайджеста» содержит «идиомы имен» и «идиомы артикля» (§§ 1–39).\nhttp://antibarbari.ru/2022/05/19/digest_1/"

В классе signature details есть пробел, достаточно на его месте поставить точку:

example_html |>
  html_element(".signature.details") |> 
  html_text2()
[1] "Olga Alieva"

Осталось добыть дату и message id:

example_html |> 
  html_element(".pull_right.date.details") |> 
  html_attr("title")
[1] "19.05.2022 11:18:07 UTC+03:00"
example_html |>
  html_element("div") |> 
  html_attr("id")
[1] "message83"

Теперь мы можем сохранить все нужные нам данные в таблицу.

tibble(id = example_html |> 
         html_element("div") |> 
         html_attr("id"),
       date = example_html |> 
         html_element(".pull_right.date.details") |> 
         html_attr("title"),
       signature = example_html |>
         html_element(".signature.details") |> 
         html_text2(),
       text = example_html |> 
         html_element(".text") |>
         html_text2()
)

8.5 Парсинг html: вложенные элементы

До сих пор наша задача упрощалась тем, что мы имели дело с игрушечным html для единственного сообщения. В настоящем html тег div повторяется на разных уровнях, и нам надо извлечь только такие div, которым соответствует определенный класс. Также не будем забывать, что архив выгрузился в виде трех html-файлов, так что понадобится наше знание итераций в purrr. Пока пробуем на одном из них:

archive_1 <- antibarbari_archive[[1]]

archive_1 |>
  html_elements("div.message.default") |> 
  head()
{xml_nodeset (6)}
[1] <div class="message default clearfix" id="message3">\n\n      <div class= ...
[2] <div class="message default clearfix" id="message5">\n\n      <div class= ...
[3] <div class="message default clearfix" id="message6">\n\n      <div class= ...
[4] <div class="message default clearfix" id="message7">\n\n      <div class= ...
[5] <div class="message default clearfix" id="message8">\n\n      <div class= ...
[6] <div class="message default clearfix" id="message9">\n\n      <div class= ...

Уже из этого набора узлов можем доставать все остальное.

archive_1_tbl <- tibble(id = archive_1 |> 
         html_elements("div.message.default") |> 
         html_attr("id"),
       date = archive_1 |> 
         html_elements("div.message.default") |> 
         html_element(".pull_right.date.details") |> 
         html_attr("title"),
       signature = archive_1 |>
         html_elements("div.message.default") |> 
         html_element(".signature.details") |> 
         html_text2(),
       text = archive_1 |> 
         html_elements("div.message.default") |> 
         html_element(".text") |>
         html_text2()
)

archive_1_tbl

Обратите внимание, что мы сначала извлекаем нужные элементы при помощи html_elements(), а потом применяем к каждому из них html_element(). Это гарантирует, что в каждом столбце нашей таблицы равное число наблюдений, т.к. функция html_element(), если она не может найти, например, подпись, возвращает NA.

Как вы уже поняли, теперь нам надо проделать то же самое для двух других файлов из архива антиварваров, а значит пришло время превратить наш код в функцию.

scrape_antibarbari <- function(html_file){
  messages_tbl <- tibble(
    id = html_file |>
      html_elements("div.message.default") |>
      html_attr("id"),
    date = html_file |>
      html_elements("div.message.default") |>
      html_element(".pull_right.date.details") |>
      html_attr("title"),
    signature = html_file |>
      html_elements("div.message.default") |>
      html_element(".signature.details") |>
      html_text2(),
    text = html_file |>
      html_elements("div.message.default") |>
      html_element(".text") |>
      html_text2()
  )
  messages_tbl
}


messages_tbl <- map_df(antibarbari_archive, scrape_antibarbari)

Вот что у нас получилось.

messages_tbl

8.6 Разведывательный анализ

Создатели канала не сразу разрешили подписывать посты, поэтому для первых нескольких десятков подписи не будет. Кроме того, в некоторых постах только фото, для них в столбце text – NA, их можно сразу отсеять.

messages_tbl <- messages_tbl |>
  filter(!is.na(text))

messages_tbl

Также преобразуем столбец, в котором хранится дата и время. Разделим его на два и выясним, в какое время и день недели чаще всего публикуются сообщения.

Задание

Из курса Getting and Cleaning Data в swirl будет полезно пройти урок Dates and Times with lubridate.

messages_tbl2 <- messages_tbl |> 
  separate(date, into = c("date", "time", NA), sep = " ") |> 
  mutate(date = dmy(date), 
         time = hms(time)) |> 
  mutate(year = year(date), 
        month = month(date, label = TRUE),
        wday = wday(date, label = TRUE),
        hour = hour(time),
        length = str_count(text, " ") + 1) |> 
  mutate(wday = factor(wday, levels = c("Sun", "Sat", "Fri", "Thu", "Wed", "Tue", "Mon")))


messages_tbl2
summary1 <- messages_tbl2 |> 
  group_by(year, month) |> 
  summarise(n = n()) 

summary1
summary2 <- messages_tbl2 |> 
  group_by(year, hour) |> 
  summarise(n = n()) |> 
  mutate(hour = case_when(hour == 0 ~ 24,
                          .default = hour))

summary2
summary3 <- messages_tbl2 |> 
   group_by(wday) |> 
   summarise(n = n())

summary3
library(gridExtra)
library(grid)

p1 <- summary1 |> 
  ggplot(aes(month, n, color = as.factor(year), group = year)) +
  geom_line(show.legend = FALSE, linewidth = 1.2, alpha = 0.8) +
  labs(title = "Число постов в месяц") +
  theme(legend.title = element_blank(), 
        legend.position = c(0.8, 0.3),
        title = element_text(face="italic")) +
  labs(x = NULL, y = NULL) +
  scale_color_viridis_d()


p2 <- summary2 |> 
  ggplot(aes(hour, n, color = as.factor(year), group = year)) + 
  geom_line(linewidth = 1.2, alpha = 0.8) +
  scale_x_continuous(breaks = seq(1,24,1)) +
  labs(x = NULL, y = NULL, title = "Время публикации поста") + 
  theme(legend.title = element_blank(), 
        legend.position = "left",
        axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        title = element_text(face="italic")
        ) +
  coord_polar(start = 0) +
  scale_color_viridis_d()


p3 <- summary3 |> 
  ggplot(aes(wday, n, fill = wday)) + 
  geom_bar(stat = "identity", 
           show.legend = FALSE) + 
  coord_flip() + 
  labs(x = NULL, y = NULL, title  = "Публикации по дням недели") +
  theme(title = element_text(face="italic"))


p4 <- messages_tbl2 |> 
  ggplot(aes(as.factor(year), length, fill = as.factor(year))) +
  geom_boxplot(show.legend = FALSE) +
  labs(title = "Длина поста по годам") + 
  labs(x = NULL, y = NULL) + 
  scale_fill_viridis_d() + 
  theme(title = element_text(face="italic"))


grid.arrange(p1, p2, p3, p4, nrow = 2,
             top =  textGrob("Телеграм-канал Antibarbari HSE",
                    gp=gpar(fontsize=16)),
             bottom = textGrob("@Rantiquity",
                    gp = gpar(fontface = 3, fontsize = 9), hjust = 1, x = 1)) 

8.7 Html таблицы

Если вам повезет, то ваши данные уже будут храниться в HTML-таблице, и их можно будет просто считать из этой таблицы1. Распознать таблицу в браузере обычно несложно: она имеет прямоугольную структуру из строк и столбцов, и ее можно скопировать и вставить в такой инструмент, как Excel.

Таблицы HTML строятся из четырех основных элементов: <table>, <tr> (строка таблицы), <th> (заголовок таблицы) и <td> (данные таблицы). Мы соберем информацию о проектных группах ФГН в 2022-2024 г.

html <- read_html("https://hum.hse.ru/proj/project2022_2024")
my_table <- html |>  
  html_element(".bordered") |> 
  html_table()

my_table
Задание

С сайта Новой философской энциклопедии извлеките список слов на букву П. Используйте map_df() для объединения таблиц.

Вопрос

Сколько всего слов на букву П в НФЭ?

8.8 Wikisource

Многие тексты доступны на сайте Wikisource.org. Попробуем извлечь все сказки Салтыкова-Щедрина.

url <- "https://ru.wikisource.org/wiki/%D0%9C%D0%B8%D1%85%D0%B0%D0%B8%D0%BB_%D0%95%D0%B2%D0%B3%D1%80%D0%B0%D1%84%D0%BE%D0%B2%D0%B8%D1%87_%D0%A1%D0%B0%D0%BB%D1%82%D1%8B%D0%BA%D0%BE%D0%B2-%D0%A9%D0%B5%D0%B4%D1%80%D0%B8%D0%BD"
html = read_html(url)

Для того, чтобы справиться с такой страницей, пригодится Selector Gadget (расширение для Chrome). Вот тут можно посмотреть короткое видео, как его установить. При помощи селектора выбираем нужные уровни.

toc <- html |> 
  html_elements("ul:nth-child(22) a")

head(toc)
{xml_nodeset (6)}
[1] <a href="/wiki/%D0%9F%D0%BE%D0%B2%D0%B5%D1%81%D1%82%D1%8C_%D0%BE_%D1%82%D ...
[2] <a href="/wiki/%D0%93%D0%BE%D0%B4%D0%BE%D0%B2%D1%89%D0%B8%D0%BD%D0%B0_(%D ...
[3] <a href="/wiki/%D0%9F%D1%80%D0%BE%D0%BF%D0%B0%D0%BB%D0%B0_%D1%81%D0%BE%D0 ...
[4] <a href="/wiki/%D0%94%D0%B8%D0%BA%D0%B8%D0%B9_%D0%BF%D0%BE%D0%BC%D0%B5%D1 ...
[5] <a href="/wiki/%D0%9F%D1%80%D0%B5%D0%BC%D1%83%D0%B4%D1%80%D1%8B%D0%B9_%D0 ...
[6] <a href="/wiki/%D0%A1%D0%B0%D0%BC%D0%BE%D0%BE%D1%82%D0%B2%D0%B5%D1%80%D0% ...

Теперь у нас есть список ссылок.

tales <- tibble(
  title = toc |>
    html_attr("title"),
  href = toc |> 
    html_attr("href")
)

Данных о годе публикации под тегом нет; надо подняться на уровень выше:

toc2 <- html |> 
  html_elements("ul:nth-child(22) li")

head(toc2)
{xml_nodeset (6)}
[1] <li>\n<a href="/wiki/%D0%9F%D0%BE%D0%B2%D0%B5%D1%81%D1%82%D1%8C_%D0%BE_%D ...
[2] <li>\n<a href="/wiki/%D0%93%D0%BE%D0%B4%D0%BE%D0%B2%D1%89%D0%B8%D0%BD%D0% ...
[3] <li>\n<a href="/wiki/%D0%9F%D1%80%D0%BE%D0%BF%D0%B0%D0%BB%D0%B0_%D1%81%D0 ...
[4] <li>\n<a href="/wiki/%D0%94%D0%B8%D0%BA%D0%B8%D0%B9_%D0%BF%D0%BE%D0%BC%D0 ...
[5] <li>\n<a href="/wiki/%D0%9F%D1%80%D0%B5%D0%BC%D1%83%D0%B4%D1%80%D1%8B%D0% ...
[6] <li>\n<a href="/wiki/%D0%A1%D0%B0%D0%BC%D0%BE%D0%BE%D1%82%D0%B2%D0%B5%D1% ...
toc2 |>
  html_text2()
 [1] "Повесть о том, как один мужик двух генералов прокормил, 1869"
 [2] "Годовщина, 1869"                                             
 [3] "Пропала совесть, 1869"                                       
 [4] "Дикий помещик, 1869"                                         
 [5] "Премудрый пискарь, 1883"                                     
 [6] "Самоотверженный заяц, 1883"                                  
 [7] "Бедный волк, 1883"                                           
 [8] "Добродетели и Пороки, 1884"                                  
 [9] "Медведь на воеводстве, 1884"                                 
[10] "Обманщик-газетчик и легковерный читатель, 1884"              
[11] "Вяленая вобла, 1884"                                         
[12] "Орёл-меценат, 1884"                                          
[13] "Карась-идеалист, 1884"                                       
[14] "Игрушечного дела людишки, 1879, 1886"                        
[15] "Чижиково горе, 1884"                                         
[16] "Верный Трезор, 1885"                                         
[17] "Недреманное око, конец 1885 или начало 1886"                 
[18] "Дурак, 1885"                                                 
[19] "Соседи, 1885"                                                
[20] "Здравомысленный заяц, 1885"                                  
[21] "Либерал, 1885"                                               
[22] "Баран-непомнящий, 1885"                                      
[23] "Коняга, 1855"                                                
[24] "Кисель, 1855"                                                
[25] "Праздный разговор, 1886"                                     
[26] "Деревенский пожар, 1885"                                     
[27] "Путём-дорогою, 1886"                                         
[28] "Богатырь, 1886"                                              
[29] "Гиена, 1886"                                                 
[30] "Приключение с Крамольниковым, 1886"                          
[31] "Христова ночь, 1886"                                         
[32] "Ворон-челобитчик, 1886"                                      
[33] "Рождественская сказка, 1886"                                 

Соединяем:

tales <- tibble(
  title_year = toc2 |>
    html_text2(),
  href = toc |> 
    html_attr("href")
)

tales

Дальше можно достать текст для каждой сказки. Потренируемся на одной. Снова привлекаем Selector Gadget для составления правила.

url_test <- tales |> 
  filter(row_number() == 1) |> 
  mutate(link = paste0("https://ru.wikisource.org", href)) |> 
  pull(link)

text <- read_html(url_test) |> 
  html_elements(".indent p") |> 
  html_text2() 

text[1]
[1] "Жили да были два генерала, и так как оба были легкомысленны, то в скором времени, по щучьему велению, по моему хотению, очутились на необитаемом острове."
text[length(text)]
[1] "Однако, и об мужике не забыли; выслали ему рюмку водки да пятак серебра: веселись, мужичина!"

Первый и последний параграф достали верно; можно обобщать.

tales <- tales |> 
    mutate(href = paste0("https://ru.wikisource.org", href))
urls <- tales |> 
  pull(href)

Функция для извлечения текстов.

get_text <- function(url) {
  read_html(url) |> 
  html_elements(".indent p") |> 
  html_text2() |> 
  paste(collapse= " ")
}
tales_text <- map(urls, get_text)

Несколько сказок не выловились: там другая структура html, но в целом все получилось.

tales_text <- tales_text |>
  flatten_chr() |> 
  as_tibble()

tales <- tales |> 
  bind_cols(tales_text)
tales

Дальше можно разделить столбец с названием и годом и, например, удалить ссылку, она больше не нужна. Разделить по запятой не получится, т.к. она есть в некоторых названиях.

tales <- tales |> 
  select(-href) |> 
  separate(title_year, into = c("title", "year"), sep = -5) |> 
  mutate(title = str_remove(title, ",$"))
tales

Недостающие две сказки я не буду пытаться извлечь, но логику вы поняли.


Поздравляем, на этом закончился первый большой раздел нашего курса “Основы работы в R” 🎐. За восемь уроков вы познакомились с основными структурами данных в R, научились собирать и трансформировать данные, строить графики, писать функции и циклы, а также готовить html-отчеты о своих исследованиях. Впереди нас ждут методы анализа текстовых данных.


  1. https://r4ds.hadley.nz/webscraping#tables↩︎